E-검증 승인 정책 실습

개요

검증 승인 정책 기능을 직접적으로 테스트해보자.
일전에 Kyverno로 검증 정책을 만드는 연습을 해봤는데, 과연 이후에 개발되어 내장된 VAP는 간편할까?

VagrantKubernetes v1.32 - Penelope 버전을 구축해 사용한다.
일단 E-Kyverno 기본 실습에서 진행한 검증 예시를 먼저 사용할 것이다.

default 네임스페이스 exec 행위 막기

원래는 로그 조회 행위를 막을 생각이었는데, 이건 내 생각이 애초에 잘못된 것이었다.
자세한 내용은 S-exec 명령어가 승인 제어에 걸리는 이유 참조.
아무튼 exec을 막는 실습으로 선회한다.

apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingAdmissionPolicy
metadata:
  name: "deny-log-policy"
spec:
  failurePolicy: Fail
  matchConstraints:
    resourceRules:
    - apiGroups:   [""]
      apiVersions: ["v1"]
      # operations:  ["*"]
      operations:  ["CREATE", "UPDATE"]
      resources:   ["pods"]
      scope: Namespaced
  validations:
    - expression: "object.spec.replicas <= 5"

일단 아주 간단하게 정책을 만들었는데, 이전 예제를 따라하면서 타입체크 기능도 확인하고자 일부러 잘못된 표현식을 작성했다.

k describe validatingadmissionpolicy deny-log-policy

image.png
리소스 쪽에서 와일드카드를 사용하지 않으면 확실하게 타입체크 기능이 발동된다.

apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingAdmissionPolicy
metadata:
  name: "deny-exec-policy"
spec:
  failurePolicy: Fail
  matchConstraints:
    resourceRules:
    - apiGroups:   [""]
      apiVersions: ["v1"]
      operations:  ["CONNECT"]
      resources:   ["pods/exec"]
  validations:
    - expression: "false"
      messageExpression: "'subResource :: ' + string(request.subResource) + ' is denied for ' + string(request.requestKind.kind) "

만약 파드에 exec을 하는 경우에는 무조건 거짓을 반환하도록 만들었다.

apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingAdmissionPolicyBinding
metadata:
  name: "default-deny-exec"
spec:
  policyName: "deny-exec-policy"
  validationActions: [Deny]
  matchResources:
    namespaceSelector:
      matchLabels:
        kubernetes.io/metadata.name: default

default 네임스페이스에 대해 적용되도록 설정했다.
image.png

The pods "debug" is invalid: : ValidatingAdmissionPolicy 'deny-exec-policy' with binding 'default-deny-exec' denied request: subResource :: exec is denied for PodExecOptions

성공적으로 실패하는 것이 확인된다.
또한 의도한 대로 메시지도 출력되고 있다.

hostPath 볼륨 만들기 막기

apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingAdmissionPolicy
metadata:
  name: "deny-hostpath-policy"
spec:
  failurePolicy: Fail
  matchConstraints:
    resourceRules:
    - apiGroups:   [""]
      apiVersions: ["v1"]
      operations:  ["CREATE", "UPDATE"]
      resources:   ["pods"]
  validations:
    - expression: "!object.spec.volumes.exists(x, has(x.hostPath))"
      message: "nono hospath nono"
---
apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingAdmissionPolicyBinding
metadata:
  name: "default-deny-hostpath"
spec:
  policyName: "deny-hostpath-policy"
  validationActions: [Deny]
  matchResources:
    namespaceSelector:
      matchLabels:
        kubernetes.io/metadata.name: default

이번에는 간단하게 hostPath 볼륨 마운팅을 막아본다.
exists로 하고 not을 붙인 이유는 all로 할 경우 모든 볼륨을 검사하게 되기 때문이다.
그래서 hostPath 볼륨이 한번이라도 나오는 순간 true를 반환하고, 그것에 not을 붙여서 빠르게 연산되도록 만들었다.

apiVersion: v1
kind: Pod
metadata:
  name: "hostpath-pod"
spec:
  containers:
  - name: hostpath-pod
    image: "debian-slim:latest"
    volumeMounts:
    - name: localtime
      mountPath: /etc/localtime
  volumes:
    - name: localtime
      hostPath:
        path: /usr/share/zoneinfo/Asia/Taipei

사용하는 못된 파드는 이렇게 생겼다.
image.png
노노 호패 노노.

apiVersion: apps/v1
kind: Deployment
metadata:
  name: hostpath2
spec:
  selector:
    matchLabels:
      app: hostpath2
  replicas: 1
  template:
    metadata:
      labels:
        app: hostpath2
    spec:
      containers:
      - name: hostpath2
        image: nginx
        volumeMounts:
        - name: localtime
          mountPath: /etc/localtime
      volumes:
        - name: localtime
          hostPath:
            path: /usr/share/zoneinfo/Asia/Taipei
      restartPolicy: Always

이렇게 막으면 워크로드를 이용할 수 있지 않을까 하는 생각이 들겠지만..
image.png
레플리카셋이 아무리 파드를 만들고 싶어해도 파드 생성 자체가 막히기 때문에 성사되지 못한다.
당연히 이왕이면 워크로드를 만들 시점에 검증 에러를 내뱉는 게 좋을 것이다.

apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingAdmissionPolicy
metadata:
  name: "deny-hostpath-policy"
spec:
  failurePolicy: Fail
  matchConstraints:
    resourceRules:
    - apiGroups:   [""]
      apiVersions: ["v1"]
      operations:  ["CREATE", "UPDATE"]
      resources:   ["pods"]
    - apiGroups:   ["apps"]
      apiVersions: ["v1"]
      operations:  ["CREATE", "UPDATE"]
      resources:   ["deployments"]
  validations:
    - expression: "object.kind == 'Pod' ? !object.spec.volumes.exists(x, has(x.hostPath)) : true"
      messageExpression: "string(object.kind) + ' with hostpath not allowed'"
    - expression: "object.kind == 'Deployment' ? !object.spec.template.spec.volumes.exists(x, has(x.hostPath)) : true"
      messageExpression: "string(object.kind) + ' with hostpath not allowed'"

그래서 이번에는 이렇게 워크로드를 만들 때의 경우도 추가한다.
지금은 deployment만 걸었으나, 아예 와일드카드를 넣어서 다른 워크로드도 막는 것이 가능할 것이다.
삼항 연산자를 넣은 이유는 각각의 케이스를 정확하게 나누어 평가하기 위해서이다.
이상적인 방향은 오히려 따로 정책을 작성하는 것일지도 모르겠다.
image.png
파드와 디플을 한꺼번에 만드려고 시도했고, 두 경우 모두 각각의 조건식에 걸려서 실패를 한 것이 확인된다.

escalate와 bind가 있는 유저가 secret을 건드리지 못하게 막기

쿠버네티스 인가에서 권한 상승을 시킬 수 있는 대표적인 동사 escalate, bind 두 가지를 보았다.
네임스페이스 관리자 sam씨에게 이 동사를 주긴 했으나, 역시 시크릿에 대한 권한을 얻는 건 용납하지 못하겠다 크아악!
(관리자에게는 이 동사를 주는 건 고려해볼 만한 게, 자신의 네임스페이스에서 다른 유저에게 권한을 줄 능력이 있어야 할 것이다.)

apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  name: sam-default
rules:
- apiGroups: ["rbac.authorization.k8s.io"]
  resources: ["roles", "rolebindings"]
  verbs: ["get", "watch", "list", "create", "update", "delete", "bind", "escalate"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
  name: sam-default
subjects:
- kind: User
  name: sam
  apiGroup: rbac.authorization.k8s.io
roleRef:
  kind: Role
  name: sam-default
  apiGroup: rbac.authorization.k8s.io
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  name: secret-admin-role
rules:
- apiGroups: [""]
  resources: ["secrets"]
  verbs: ["get", "watch", "list", "create", "delete"]

이렇게 권한을 주었다.
여기에 추가적으로 하나의 롤을 더 만들었는데, 이것은 이미 존재하는 시크릿에 대한 롤이다.
sam은 이 롤을 이용해 자신에게 롤바인딩을 하는 것이 불가능해야 한다.

k access-matrix --as sam -n default 

image.png
일단 sam은 롤에 대한 권한은 적절하게 가지고 있다.

apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  name: sam-secret
rules:
- apiGroups: [""]
  resources: ["secrets"]
  verbs: ["get", "watch", "list", "create", "delete"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
  name: sam-secret
subjects:
- kind: User
  name: sam
  apiGroup: rbac.authorization.k8s.io
roleRef:
  kind: Role
  name: sam-secret
  apiGroup: rbac.authorization.k8s.io
--- 
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
  name: sam-using-secret-role
  namespace: default
subjects:
- kind: User
  name: sam
  apiGroup: rbac.authorization.k8s.io
roleRef:
  kind: Role
  name: secret-admin-role
  apiGroup: rbac.authorization.k8s.io

이게 sam이 시크릿을 보려고 자신의 권한을 올리는 예시이다.
image.png
bind와 escalate가 있는 한 sam은 쉽게 자신의 권한을 획득할 수 있는 상태이다.
막아야겠지?

apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingAdmissionPolicy
metadata:
  name: "deny-secret-policy"
spec:
  failurePolicy: Fail
  matchConstraints:
    resourceRules:
    - apiGroups:   ["rbac.authorization.k8s.io"]
      apiVersions: ["v1"]
      operations:  ["CREATE", "UPDATE"]
      resources:   ["roles", "rolebindings"]
  variables:
    - name: requester
      expression: "request.userInfo.username"
    - name: blacklist
      expression: "string('sam')"
  matchConditions:
    - name: 'only_subjects'
      expression: "request.userInfo.username == 'sam'"
  validations:
    - expression: "object.kind == 'Role' ? !(object.rules.exists(x, x.resources.exists(y, y == 'secrets'))) : true"
      messageExpression: "string(variables.requester) + ' is not allowed to create ' + string(object.kind) + ' about secrets'"
    - expression: "object.kind == 'RoleBinding' ? !(object.subjects.exists(x, x.name == variables.blacklist) && object.roleRef.name.startsWith('secret')) : true"
      messageExpression: "string(variables.requester) + ' is not allowed to create ' + string(object.kind) + ' about secrets'"
---
apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingAdmissionPolicyBinding
metadata:
  name: "default-deny-secret"
spec:
  policyName: "deny-secret-policy"
  validationActions: [Deny]
  matchResources:
    namespaceSelector:
      matchLabels:
        kubernetes.io/metadata.name: default

정책은 이런 식으로 짰다.
먼저 롤과 롤바인딩을 만드는 요청일 때 이 정책이 적용된다.
추가적으로, 그 요청의 주체가 sam일 경우에만 정책이 적용되도록 만들었다.
조건은 두 가지가 있는데, 먼저 롤에 대해서는 secrets가 리소스에 들어가는 순간 검증 실패 결정을 내린다.
롤 바인딩의 경우엔 바인딩을 하려는 엮으려는 주체가 sam이고 롤의 이름이 secret으로 시작될 때 실패하도록 설정했다.
롤의 이름을 가지고 체크를 하는 이유는 CEL 표현식에서는 현재 들어온 요청의 값에 대해서만 검증을 진행할 수 있기 때문이다.
실제로 롤바인딩 오브젝트가 어떤 권한을 수여하는 롤과 바인딩되려는지, 정책 검증 단계에서는 알 방법이 없다.
(이거 되게 해주면 안 될까..? 하지만 되게 하면 너무 많은 컴퓨팅 자원을 쓰게 될 것 같기도 하다.)
그래서 일단 임의로, secret 관련 롤은 이름에 무조건 secret이란 접두사가 들어간다고 가정하고 정책을 짰다.
실무 환경에서도 충분히 이런 네이밍 규칙을 지정하고 사용할 것이라 생각했다.

이러면 secret이 접두사로 들어가지 않은 secret 관련 롤을 만들어서 악용할 수 있지 않을까?
다음의 결과를 보자.

kaf sam-bad-secret.yaml --as sam

image.png
일단 시크릿 관련 롤을 만드는 요청과, 기존에 존재하는 secret 롤을 이용해 롤바인딩을 하려는 요청은 막혔다.
그러나 임의로 만들어진 시크릿 롤에 대한 롤바인딩 요청 자체는 성공한 것이 보인다.

k auth can-i --as sam create secret

image.png
그럼에도 실제로 sam은 시크릿을 만들 수 없는데, 왜냐하면 애초에 sam은 시크릿 관련 롤을 만들 수 없기 때문이다.
이로써 sam은 bind와 escalate 동사를 가지고 있더라도 secret에 대한 권한은 취득할 수 없게 됐다.
sam은 다음의 행동은 할 수 있다.

소기의 목적은 달성됐다!
여기에 롤을 만들 때는 무조건 대상이 되는 리소스를 이름에 접두사로 붙인다던가 하는 검증 규칙을 만들 수도 있을 것이다.
그러나 다 알려주면 재미 없으니 이건 이 글을 보는 분께 심화 과제로 남기고자 한다.

결론

내장된 기능이니 최대한 가볍게 설계되지 않았을까 감안하고 실습을 진행했다.
기능은 어느 정도 한정된다고 생각이 들었지만, 검증 정책을 짜는데 있어서는 충분하다고 생각이 든다.

정책을 짜는 것이 어려웠는가?
키베르노와 비교했을 때, 나는 훨씬 쉽다는 생각이 들었다.
CEL 표현식을 익혀야 한다는 게 단점이라면 단점이겠지만, 막상 해보니까 CEL 표현식은 그다지 어렵지 않았다.
image.png
일단 만들어보고 문법 오류가 있다면 이렇게 바로 에러를 내뱉어주기 때문에 생각보다 디버깅도 쉬웠다.
그래서 그냥 CEL 표현식을 공부하는 아주 조금의 수고로, 검증에 대한 것은 편하게 정책을 지정할 수 있다는 게 큰 장점으로 다가왔다.
키베르노까지 깔짝대본 바, 짧은 식견으로는 사용성은 오히려 검증 승인 정책이 더 좋다.
CEL 때문에 더 어려울 것이라 생각했는데, 막상 해보니 절대 그렇지 않다.
물론 키베르노도 검증 승인 정책 방식으로 짤 수 있도록 지원을 해주고 있어서, 이미 사용하고 있는 조직에서는 굳이 이걸 사용하겠답시고 마이그레이션을 할 필요는 절대 없다.
거기에 키베르노는 리포트와 모니터링도 가능하므로 더 다양한 기능을 지원하는 강력한 애드온인 만큼, 이 기능이 내장됐다고 해서 키베르노가 없어질 일은 절대 없다는 것이 내 생각이다.

번외 - 파라미터 이용 실패

원래는 이것보다 훨씬 더 관리가 편하고 재사용성 있도록 정책을 만들 생각이었다.

apiVersion: v1
kind: ConfigMap
metadata:
  name: "test"
  namespace: "default"
data:
  subjects: "sam"
  secret_role_prefix: "secret"
---
apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingAdmissionPolicy
metadata:
  name: "deny-secret-policy"
spec:
  failurePolicy: Fail
  paramKind:
    apiVersion: v1
    kind: ConfigMap
  matchConstraints:
    resourceRules:
    - apiGroups:   ["rbac.authorization.k8s.io"]
      apiVersions: ["v1"]
      operations:  ["CREATE", "UPDATE"]
      resources:   ["roles", "rolebindings"]
  variables:
    - name: requester
      expression: "request.userInfo.username"
    - name: blacklist
      expression: "string('sam')"
  matchConditions:
    - name: 'only_subjects'
      # expression: "request.userInfo.username in variables.blacklist"
      expression: "request.userInfo.username in string(params.data.subjects).replace(' ', '').split(',')"
  validations:
    - expression: "params != null"
      message: "params missing but required to bind to this policy"
      messageExpression: "string(params.data.subjects)"
    - expression: "object.kind == 'Role' ? !(object.rules.exists(x, x.resources.exists(y, y == 'secrets'))) : true"
      messageExpression: "string(variables.requester) + ' is not allowed to create ' + string(object.kind) + ' about secrets'"
    - expression: "object.kind == 'RoleBinding' ? !(object.subjects.exists(x, x.name == variables.blacklist) && object.roleRef.name.startsWith('secret')) : true"
      messageExpression: "string(variables.requester) + ' is not allowed to create ' + string(object.kind) + ' about secrets'"
---
apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingAdmissionPolicyBinding
metadata:
  name: "default-deny-secret"
spec:
  paramRef:
    name: "test"
    namespace: "default"
    parameterNotFoundAction: Deny
  policyName: "deny-secret-policy"
  validationActions: [Deny]
  matchResources:
    namespaceSelector:
      matchLabels:
        kubernetes.io/metadata.name: default

(테스트를 하다가 조금 바뀌었는데, 대충 이런 모양이었다.)
다른 리소스를 변수처럼 이용할 수 있다는 검증 정책 설정 특성을 적극 활용하여 동적으로 블랙리스트 대상을 지정할 수 있도록 할 생각이었다.
컨피그맵으로 블랙리스트가 될 주체와, 시크릿을 대상으로 하는 롤의 접두사도 관리를 하는 방식인 것이다.

그러나 막상 문제에 봉착한 것이, 컨피그맵 리소스가 실제 정책이 적용될 때 읽히지 않았다.
명확하게 컨피그맵이 만들어져 있는 것을 확인했음에도 계속 파라미터를 찾지 못했다는 에러가 나왔다.
반복된 테스트를 거치며, 나는 이것이 버그라고 결론 내렸다.
1.30 버전에서 테스트를 거친 사람의 글[1]을 똑같이 따라해봐도 에러가 발생했기 때문이다.
암만 봐도 이건 버그인 것 같아서, 아예 공식 레포에 이슈까지 등록했다.[2]
어쩌면 내가 잘못한 게 있지 않을까 싶기도 한데, 다른 전문가들의 피드백을 받으면서 추이를 지켜보고자 한다.

추가 대응

image.png
내 이슈 등록에 몇 가지 반응을 보인 사람들이 있어 한번 확인해본다.
문제가 없었다는 사람의 스크립트를 가져와서 context 써있는 부분만 빼고 그대로 적용해봤다.
이번에도 문제가 생기는 것이 보인다.
image.png
근데 완전히 스크립트를 베껴서 해보니 이번엔 문제가 발생하지 않았다.
혹시 이전에 에러가 난 상황이 영향을 끼치는 것은 아닐끼?

관련 문서

이름 noteType created
Validation Admission Policy knowledge 2025-03-17
E-검증 승인 정책 실습 topic/explain 2025-03-17
S-exec 명령어가 승인 제어에 걸리는 이유 topic/shooting 2025-03-17

참고


  1. https://medium.com/google-cloud/validating-admission-policies-with-gke-1-26-ed1321bcf739 ↩︎

  2. https://github.com/kubernetes/kubernetes/issues/130887 ↩︎